Skip to content

test: add static export E2E tests (#564)#686

Merged
james-elicx merged 2 commits intocloudflare:mainfrom
raed04:feat/issue-564-static-export-e2e
Mar 28, 2026
Merged

test: add static export E2E tests (#564)#686
james-elicx merged 2 commits intocloudflare:mainfrom
raed04:feat/issue-564-static-export-e2e

Conversation

@raed04
Copy link
Copy Markdown
Contributor

@raed04 raed04 commented Mar 25, 2026

Summary

  • Add Playwright E2E tests for output: "export" static builds covering both App Router and Pages Router
  • Add a lightweight static file server (serve-static.mjs) with path traversal protection, proper error handling, and startup validation
  • Wire up the static-export project in playwright.config.ts and the CI matrix

Details

New files

  • tests/e2e/static-export/app-router.spec.ts — 10 tests covering home, about, blog slugs, navigation, metadata, and 404 handling
  • tests/e2e/static-export/pages-router.spec.ts — 7 tests covering Pages Router pages, getStaticProps/getStaticPaths data, __NEXT_DATA__ verification, and 404 for non-pre-rendered routes
  • tests/e2e/static-export/serve-static.mjs — Zero-dependency static file server with:
    • Path traversal protection (URL normalization + resolve + prefix check)
    • Narrowed error handling (ENOENT/ENOTDIR/ERR_INVALID_ARG_VALUE only, real errors propagate)
    • Startup validation (port range, root directory existence + isDirectory check)
    • server.on("error") handler for EADDRINUSE
    • Top-level request try-catch preventing unhandled rejections

Modified files

  • playwright.config.ts — New static-export project entry (port 4180, 60s timeout)
  • .github/workflows/ci.yml — Add static-export to E2E matrix

Test plan

  • All 17 static-export E2E tests pass locally
  • Full CI suite passes locally: unit tests, integration tests, and all 4 non-cloudflare E2E projects (356 total E2E tests, 0 failures)
  • vp check (formatting + types) passes
  • No regressions in existing test suites

Add Playwright E2E tests for `output: "export"` static builds covering
both App Router and Pages Router. Includes a lightweight static file
server with path traversal protection, proper error handling, and
startup validation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 25, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@686

commit: eafaeba

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Static Export E2E Tests

Clean PR — well-structured tests, solid static file server, and proper CI wiring. The test coverage is good for an initial static export E2E suite (both App Router and Pages Router, including dynamic routes, metadata, __NEXT_DATA__, and 404 behavior).

One issue to address in the static file server, and a few minor suggestions below.

if (pathname.endsWith("/")) pathname += "index.html";

// Resolve file path — try exact match, then .html extension
let filePath = resolve(join(rootDir, pathname));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: resolve(join(rootDir, pathname)) is redundant since resolve with an absolute first segment in the join result already normalizes. But pathname starts with /, so join(rootDir, pathname) actually works correctly (join strips the leading / from pathname when the first arg is absolute).

Just noting this is fine as-is.

await expect(nav.locator('a[href="/products/widget"]')).toBeVisible();
});

test("client-side navigation works between pages", async ({ page }) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test verifies client-side navigation works, which is great for a static export. One thing to note: since this is a full page navigation (no vinext runtime for SPA transitions in a static export), page.waitForURL will work fine. But if static export eventually supports client-side hydration with the router, this test's behavior would change. Worth a brief comment.

Also, consider verifying the navigation didn't produce a network error (checking response status on the target page), similar to other tests in this file.

// lightweight static file server. No vinext runtime is needed —
// the output is pure pre-rendered HTML files.
command:
"npx tsc -p ../../../packages/vinext/tsconfig.json && node ../../../packages/vinext/dist/cli.js build && node ../../../tests/e2e/static-export/serve-static.mjs dist/client 4180",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tsc step compiles the entire vinext package before building — same pattern as pages-router-prod. Just confirming: is this needed because the static-export fixture depends on vinext from source (workspace dependency), and the CLI invocation (dist/cli.js) needs the compiled output? If so, this is correct.

One concern: in CI, the e2e job already runs vp run build (line 146 of ci.yml) before Playwright. So npx tsc -p ../../../packages/vinext/tsconfig.json here is redundant in CI but necessary for local development (where you might not have run the build). The pages-router-prod project has the same pattern, so this is consistent.

process.exit(1);
}

const MIME_TYPES = {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice set of MIME types. Consider adding .txt (text/plain) and .woff2 (font/woff2) for completeness — static exports sometimes include font files or robots.txt. Not blocking.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 25, 2026

Review submitted successfully. Here's a summary of the feedback I posted:

Overall: Clean PR with well-structured tests and solid static file server implementation. Good coverage across both App Router and Pages Router static export scenarios.

Key feedback points:

  1. serve-static.mjs line 80 — decodeURIComponent suggestion: new URL().pathname returns percent-encoded paths. While this is safe as-is (percent-encoded traversal attempts like %2e%2e are handled correctly by accident), adding decodeURIComponent(parsed.pathname) makes the intent clearer and makes the .html extension fallback work correctly for paths with encoded characters.

  2. app-router.spec.ts line 68 — client-side navigation test: Suggested adding a comment clarifying whether this exercises SPA navigation or full page loads, and potentially checking the response status on the target page for consistency with other tests.

  3. playwright.config.ts line 118 — tsc step in command: Noted this is consistent with the pages-router-prod pattern and confirmed it's needed for local dev (CI already runs vp run build beforehand).

  4. serve-static.mjs MIME types: Minor suggestion to add .txt and .woff2 for completeness.

  5. resolve(join(...)) note: Confirmed the path resolution logic is correct as written.

github run

Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk re-review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Static Export E2E Tests

Clean, well-structured PR. The test coverage is appropriate for an initial static export E2E suite — it exercises both App Router and Pages Router, including dynamic routes with generateStaticParams/getStaticPaths, metadata, __NEXT_DATA__, and 404 behavior. The static file server is solid and purpose-built.

I verified:

  • All test assertions match the fixture source content (titles, text, slugs, product names)
  • The Playwright config follows the same patterns as existing projects (pages-router-prod uses the same tsc + cli.js build approach)
  • The CI matrix entry is correct
  • The serve-static.mjs path traversal protection is sound (decode → resolve → prefix check)
  • The BASE constant pattern is consistent with all other E2E specs in the repo

A few minor observations below, none blocking.

process.exit(1);
});

server.listen(port, "127.0.0.1", () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The server binds to 127.0.0.1 but the log message says localhost. This is fine in practice (they resolve to the same thing), but if someone were to change this to 0.0.0.0 for debugging, the log would be misleading. Consider using the actual bind address in the log:

Suggested change
server.listen(port, "127.0.0.1", () => {
server.listen(port, "127.0.0.1", () => {
console.log(`Static server listening on http://127.0.0.1:${port}`);
});

Not blocking — localhost is clearer for most people.

await expect(page).toHaveTitle("Static Export Fixture");
});

test("404 page for non-existent route", async ({ page }) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 404 test depends on serve-static.mjs returning a 404 status for missing files. If vinext's static export generates a 404.html file, the server serves it with status 404 (line 108 of serve-static.mjs). If vinext does not generate 404.html, the server still returns 404 with a plain text body.

Either way the test passes, but the behavior being tested is subtly different. It might be worth adding a follow-up test that checks the 404 content (e.g., whether the custom 404 page is rendered) to verify vinext's static export actually produces a 404.html. That would be a more meaningful signal for issue #564 coverage.

Not blocking for this PR — just a suggestion for a follow-up.

await expect(page.locator("body")).toContainText("Product ID: doohickey");
});

test("__NEXT_DATA__ is present in Pages Router output", async ({ page }) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good test — verifying __NEXT_DATA__ is present and correctly shaped is exactly the right thing to check for Pages Router static export parity. This catches the case where vinext's static export might emit the HTML but forget the data script tag.

if (pathname.endsWith("/")) pathname += "index.html";

// Resolve file path — try exact match, then .html extension
let filePath = resolve(join(rootDir, pathname));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth noting for future readers: join(rootDir, pathname) works correctly here even though pathname starts with /. Node's path.join strips the leading / from non-first arguments, so join('/srv/root', '/about') produces /srv/root/about, not /about. The subsequent resolve() then normalizes any remaining .. segments.

The two-step resolve(join(...)) + isInsideRoot() is the correct pattern — resolve handles normalization, and the prefix check catches any traversal that survives.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 28, 2026

Review submitted successfully. Here's a summary of the feedback I posted:

Verdict: Approved

This is a clean, well-structured PR. The test coverage is appropriate for an initial static export E2E suite covering both App Router and Pages Router, including dynamic routes, metadata, __NEXT_DATA__, and 404 behavior. The static file server is solid and purpose-built.

What I verified:

  • All test assertions match the fixture source content (titles, text, slugs, product names)
  • The Playwright config follows existing project patterns (pages-router-prod uses the same tsc + cli.js build approach)
  • The CI matrix entry is correct
  • The serve-static.mjs path traversal protection is sound (decode -> resolve -> prefix check)
  • The BASE constant pattern is consistent with all 73 other E2E specs in the repo

Inline comments posted:

  1. serve-static.mjs:141 — Minor: server binds to 127.0.0.1 but logs localhost; suggested using the actual bind address for consistency
  2. app-router.spec.ts:80 — The 404 test passes regardless of whether vinext generates a 404.html; suggested a follow-up test that checks 404 content to verify vinext's static export produces the page
  3. pages-router.spec.ts:43 — Positive note on the __NEXT_DATA__ verification test
  4. serve-static.mjs:86 — Explanatory note confirming the resolve(join(...)) path resolution pattern is correct

github run

@james-elicx james-elicx merged commit a515f07 into cloudflare:main Mar 28, 2026
22 checks passed
@james-elicx james-elicx linked an issue Mar 28, 2026 that may be closed by this pull request
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Setup static export fixture as an E2E in pipelines

2 participants